48 — 混合 HTML 与 WebGL

Three.js Journey · Bruno Simon · 难度:简单

48 — 混合 HTML 与 WebGL 课程海报

简介

在本课中,我们将学习如何把 HTML 集成到 scene 中。所谓集成,就是让一个可交互的 HTML 元素跟随 scene 上某个特定的 3D 位置,使其看起来像是 WebGL 的一部分。

为了演示这一点,我们将为一个 model 添加兴趣点。这些兴趣点将用 HTML 制作,并且始终黏附在其关联的 3D 位置上。而且由于这些点是用 HTML 制作的,我们就可以用 CSS 来设计它们,添加像 :hover 这样的交互,并添加过渡效果。

初始设置

起始文件由我们在 Intro and loading progress 课程以及 Post-processing 课程中所做的工作组成,使用的是 Damaged Helmet,其作者为 Leonardo Carrion。

HTML

首先,让我们创建一个 HTML 点。在课程结束时,我们会添加更多的点。

在 /src/index.html 文件中,在 <canvas> 和加载进度条之后添加这个点:

<canvas class="webgl"></canvas>

<div class="loading-bar"></div>

<div class="point point-0">
    <div class="label">1</div>
    <div class="text">Lorem ipsum, dolor sit amet consectetur adipisicing elit</div>
</div>

我们放在该点内部的文本不应该在页面上可见,因为它被隐藏在 <canvas> 后面。

我们用 point 类来在 CSS 中定位所有的点,同时也用 point-0 类来在 JavaScript 中定位这个特定的元素。后续的点将使用 point-1、point-2 等等。

我们的点由一个带有 label 类的 <div> 和一个带有 text 类的 <div> 组成。

label 将是那个看起来像是黏在 model 上的小圆形数字,而 text 会在我们悬停该 label 时显示出来。

CSS

我们将一次性把整个 CSS 全部添加进去。通常你需要循序渐进地做,但我们已经知道目标是什么。

打开 /src/style.css 文件,首先把点定位到屏幕中央:

.point
{
    position: absolute;
    top: 50%;
    left: 50%;
}

你应该能看到黑色文本出现在屏幕中央。我们从中心开始,因为 Three.js 提供的坐标中 0 就是屏幕的中心。

让我们先设计 label:

.point .label
{
    position: absolute;
    top: -20px;
    left: -20px;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    background: #00000077;
    border: 1px solid #ffffff77;
    color: #ffffff;
    font-family: Helvetica, Arial, sans-serif;
    text-align: center;
    line-height: 40px;
    font-weight: 100;
    font-size: 14px;
}

你应该能看到这个 label。

这里没什么难的。我们只是用 border-radius: 50%; 把元素变成圆形,用 background: #00000077; 设置了降低不透明度的黑色背景;并用 border: 1px solid #ffffff77; 对边框做了同样的处理。然后,我们把白色文字居中在元素中间。

让我们对 text 做同样的处理:

.point .text
{
    position: absolute;
    top: 30px;
    left: -120px;
    width: 200px;
    padding: 20px;
    border-radius: 4px;
    background: #00000077;
    border: 1px solid #ffffff77;
    color: #ffffff;
    line-height: 1.3em;
    font-family: Helvetica, Arial, sans-serif;
    font-weight: 100;
    font-size: 14px;
}

你应该能看到文本出现在 label 下方。

我们这里实现的效果与 label 非常相似,只是使用了固定的 border-radius、特定的 line-height 以及 padding。

现在我们已经有了两个元素,接下来准备交互。首先,我们希望文本默认是隐藏的。

把它的 opacity 设为 0:

.point .text
{
   /* ... */
    opacity: 0;
}

当 .point 被悬停时,我们希望文本显示出来:

.point:hover .text
{
    opacity: 1;
}
lesson demo video preview

当 label 被悬停时,文本应该显示出来。

与其这样生硬地出现,我们希望文本能够淡入淡出。为此,我们可以使用 transition。

把 transition 添加到 text 上,而不仅仅是在悬停时。这样,当光标离开该点时过渡也会发生:

.point .text
{
    /* ... */
    transition: opacity 0.3s;
}
lesson demo video preview

文本应该能够淡入淡出。

我们遇到一个小问题。当文本不可见时,我们仍然可以直接悬停在它上面。

lesson demo video preview

要解决这个问题,我们可以停用 text 上的指针事件。这可以通过 pointer-events CSS 属性来实现:

.point .text
{
    /* ... */
    pointer-events: none;
}
lesson demo video preview

文本不应该能够被“悬停”。

目前,当我们悬停 label 中的数字时,光标会变成文本选择样式。要解决这个问题,我们可以用 cursor CSS 属性来更改整个 label 上的光标。

.point .label
{
    /* ... */
    cursor: help;
}
lesson demo video preview

悬停 label 时,光标位置应该出现一个“?”。

我们几乎完成了 CSS 的工作。现在需要做的是准备一种显示和隐藏 label 的方式。所以,我们会默认隐藏它们,只有当点上存在 visible 类时才显示。

为了隐藏它们,我们将使用带 scale 的 transform,同时还会添加 transition,以便对 scale 进行动画:

.point .label
{
    /* ... */
    transform: scale(0, 0);
    transition: transform 0.3s;
}

.point.visible .label
{
    transform: scale(1, 1);
}

label 应该消失了。但如果我们在 Developer Tools 面板中直接给元素添加 visible 类,就应该能看到 label 显示出来。

lesson demo video preview

目前,我们先直接在 HTML 中添加 visible 类,以便给这些点定位。稍后我们会移除这个类。

<div class="point point-0 visible">

现在这个点应该是可见的。

JavaScript

现在 HTML 和 CSS 都已经准备就绪,我们可以切换到 JavaScript 了。

存储这些点

首先,我们需要一种方式来存储所有的点——即使现在只有一个。我们将使用一个对象数组,每个对象对应一个点。

每个点对象将有两个属性:3D 位置以及对 HTML 元素的引用。

创建 points 数组,并在其中放入一个点:

const points = [
    {
        position: new THREE.Vector3(1.55, 0.3, - 0.6),
        element: document.querySelector('.point-0')
    }
]

我们使用 Vector3 表示位置,并用 document.querySelector(...) 从 HTML 中获取该元素。

更新点的位置

我们将直接在 tick 函数中逐帧更新这些点的 HTML 元素。

在 tick 函数中,紧跟在更新 controls 之后,遍历 points 数组中的每个元素——即使我们只有一个。你可以使用任何循环方式,但我们这里用 for(... of ...):

const tick = () =>
{
    // Update controls
    controls.update()

    // Go through each point
    for(const point of points)
    {
        
    }

    // ...
}

我们需要获取点的 3D scene 位置所对应的 2D 屏幕位置。这部分相当直接。

首先,我们需要克隆点的位置。这很重要,因为接下来的代码会直接修改这个 Vector3。要克隆位置,使用 clone() 方法:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
    }

    // ...
}

要获取 2D 屏幕位置,我们需要调用 project(...) 方法,并把 camera 作为参数传入:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        console.log(screenPosition.x)
    }

    // ...
}

你应该会在日志中看到一个接近 0 的值。如果你移动 camera,让头盔位于最左侧——使用右键拖拽 & 放置——你会得到一个接近 -1 的值。如果你移动 camera,让头盔位于最右侧,你会得到一个接近 +1 的值。

我们先专注于 x 值,稍后再处理 y。

问题在于我们不能直接这样使用这个值。我们希望能够用像素来移动 point 元素。

要从这个投影后的屏幕位置转换到屏幕上的像素,我们需要乘以渲染尺寸的一半,而我们已经有了这个值,它就在 sizes 对象中:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        const translateX = screenPosition.x * sizes.width * 0.5
        console.log(translateX)
    }

    // ...
}

再次移动 camera,把头盔放到两侧的极远处,然后看看日志中的值。它应该有几百。

现在这个值看起来对了,让我们更新 point 元素。为此,我们将在 style 的 transform 属性中应用平移。别忘了在 translateX 函数结尾写上 px:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        const translateX = screenPosition.x * sizes.width * 0.5
        point.element.style.transform = `translateX(${translateX}px)`
    }

    // ...
}
lesson demo video preview

这个点在 X 轴上看起来移动得很好。让我们添加 Y 轴。

使用 y 属性和 sizes.height 值添加一个 translateY 变量。然后,把它应用到 transform 中:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        const translateX = screenPosition.x * sizes.width * 0.5
        const translateY = screenPosition.y * sizes.height * 0.5
        point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`
    }

    // ...
}
lesson demo video preview

我们就快完成了,可惜 Y 轴似乎是反的。确实如此,在 CSS 中,正的 translateY 向下,而在 Three.js 中,正的 y 向上。

我们需要把 translateY 变量取负:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        const translateX = screenPosition.x * sizes.width * 0.5
        const translateY = - screenPosition.y * sizes.height * 0.5
        point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`
    }

    // ...
}
lesson demo video preview

point 元素应该能完美地跟随它的 3D 位置。

显示和隐藏元素

最后,如果点前面有东西,我们需要隐藏这个点。

首先,移除 HTML 中的 visible 类:

<div class="point point-0">

要测试点前面是否有东西,我们将使用 Raycaster。我们会从 camera 向该点发射一条射线。如果没有相交的 object,我们就显示该点。如果有东西,我们就测试交点的距离。如果交点比该点更远,说明 object 在该点后面,我们可以显示它。如果交点比该点更近,说明相交的 object 在该点前面,我们就隐藏它。

让我们在 tick 函数之外的任意位置创建一个 Raycaster:

const raycaster = new THREE.Raycaster()

在 tick 函数中,更新 Raycaster,使其从 camera 指向该点。为此,使用 setFromCamera(...)。

如果你还记得 Raycaster 课程,setFromCamera(...) 的第一个参数应该是一个对应屏幕上位置的 Vector2,第二个参数应该是 camera。

在 Raycaster 课程中,我们需要把光标的屏幕位置(以像素为单位)转换成适配 Three.js 坐标的值。不过在我们的情况下,我们已经有了这些值。我们可以改用 screenPosition。是的,screenPosition 是一个 Vector3,但只会用到它的 x 和 y 属性:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        raycaster.setFromCamera(screenPosition, camera)

        // ...
    }

    // ...
}

我们将用 intersectObjects(...) 方法让 Raycaster 对 scene 中的每个 object 进行检测。

为此,把 scene.children 作为第一个参数,把 true 作为第二个参数。第二个参数将启用递归检测,也就是说算法还会遍历子级的子级,以及子级的子级的子级,等等:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        raycaster.setFromCamera(screenPosition, camera)
        const intersects = raycaster.intersectObjects(scene.children, true)

        // ...
    }

    // ...
}

首先,我们可以先检测是否存在交点。如果没有交点,说明点前面没有 object,我们可以显示它。如果存在交点,我们还需要做更多的测试,但目前,我们先直接隐藏这个点。

要显示该点,我们可以用 classList.add(...) 添加 visible 类。而要隐藏该点,我们可以用 classList.remove(...) 移除 visible 类:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        raycaster.setFromCamera(screenPosition, camera)
        const intersects = raycaster.intersectObjects(scene.children, true)

        if(intersects.length === 0)
        {
            point.element.classList.add('visible')
        }
        else
        {
            point.element.classList.remove('visible')
        }

        // ...
    }

    // ...
}
lesson demo video preview

不完全是我们要的效果,但我们正接近了。

问题在于只要有东西与射线相交我们就会隐藏这个点。但如果交点在该点后面,那么这个点就不应该被隐藏。

我们需要计算到该点的距离,然后计算交点的距离,并对它们进行比较。

intersectObjects(...) 方法返回一个交点数组。这些交点按距离排序,最近的排在最前。这意味着如果有多个交点,我们不必测试所有的交点,只需测试第一个即可。

要获取交点距离并不难,因为这个值已经作为 distance 属性存在于交点对象中:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        raycaster.setFromCamera(screenPosition, camera)
        const intersects = raycaster.intersectObjects(scene.children, true)

        if(intersects.length === 0)
        {
            point.element.classList.add('visible')
        }
        else
        {
            const intersectionDistance = intersects[0].distance
        }

        // ...
    }

    // ...
}

获取该点的距离稍难一些。我们需要从点的 position 开始,由于它是一个 Vector3,我们可以使用它的 distanceTo() 方法。

这个方法需要另一个 Vector3 作为参数。它会计算第一个和第二个之间的距离。我们可以使用 camera 的 position:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        raycaster.setFromCamera(screenPosition, camera)
        const intersects = raycaster.intersectObjects(scene.children, true)

        if(intersects.length === 0)
        {
            point.element.classList.add('visible')
        }
        else
        {
            const intersectionDistance = intersects[0].distance
            const pointDistance = point.position.distanceTo(camera.position)
        }

        // ...
    }

    // ...
}

现在我们需要做的就是比较这两个距离。如果 intersectionDistance 小于 pointDistance,说明相交的 object 比该点更近,应当被隐藏。否则,该点应当显示:

const tick = () =>
{
    // ...

    for(const point of points)
    {
        const screenPosition = point.position.clone()
        screenPosition.project(camera)

        raycaster.setFromCamera(screenPosition, camera)
        const intersects = raycaster.intersectObjects(scene.children, true)

        if(intersects.length === 0)
        {
            point.element.classList.add('visible')
        }
        else
        {
            const intersectionDistance = intersects[0].distance
            const pointDistance = point.position.distanceTo(camera.position)

            if(intersectionDistance < pointDistance)
            {
                point.element.classList.remove('visible')
            }
            else
            {
                point.element.classList.add('visible')
            }
        }

        // ...
    }

    // ...
}
lesson demo video preview

现在一切都正常工作了。

等待 scene 准备就绪

我们现在遇到的问题是,scene 加载时这些关键点是可见的。一个简单的解决方法是创建一个初始为 false 的变量,并在一切就绪后把它设为 true。在 tick 函数中,只有当这个变量为 true 时我们才更新这些点。

创建一个初始为 false 的 sceneReady 变量,并在 loadingManager 的成功函数中,在 2000ms 之后把它设为 true:

let sceneReady = false
const loadingManager = new THREE.LoadingManager(
    // Loaded
    () =>
    {
        // ...

        window.setTimeout(() =>
        {
            sceneReady = true
        }, 2000)
    },

    // ...
)

最后,在 tick 函数中,把整个点循环放入一个 if 语句中:

const tick = () =>
{
    // ...

    if(sceneReady)
    {
        for(const point of points)
        {
            // ...
        }
    }

    // ...
}
lesson demo video preview

这些点应该只在 scene 准备就绪、并且开场动画几乎完全消失时才显示。

添加更多的点

就是这样!我们的点运行得很好。现在我们可以添加更多的点,并把这些文本改成更有说服力的内容。

在 HTML 中:

<div class="point point-0">
    <div class="label">1</div>
    <div class="text">Front and top screen with HUD aggregating terrain and battle informations.</div>
</div>
<div class="point point-1">
    <div class="label">2</div>
    <div class="text">Ventilation with air purifier and detection of environment toxicity.</div>
</div>
<div class="point point-2">
    <div class="label">3</div>
    <div class="text">Cameras supporting night vision and heat vision with automatic adjustment.</div>
</div>

在 JavaScript 中:

const points = [
    {
        position: new THREE.Vector3(1.55, 0.3, - 0.6),
        element: document.querySelector('.point-0')
    },
    {
        position: new THREE.Vector3(0.5, 0.8, - 1.6),
        element: document.querySelector('.point-1')
    },
    {
        position: new THREE.Vector3(1.6, - 1.3, - 0.7),
        element: document.querySelector('.point-2')
    }
]
lesson demo video preview

进阶

我们这里所做的只是众多方法中的一种。你可能会有不同的做法,甚至可能更好。这取决于你、项目、动画、性能、代码需要多灵活等等。

性能方面也有改进空间。目前我们在每一帧都更新所有的点。我们可以只更新可见的那些。

请记住,将 HTML 与 WebGL 结合通常对性能不利。尽量避免这样做,如果你别无选择,请留意帧率,并定期在不同的设备上进行测试。